Skip to content

Close the action contract: one registry, no prose fallback (#241 follow-up) - #441

Merged
jeremymanning merged 1 commit into
mainfrom
fix/action-contract-closure
Aug 1, 2026
Merged

Close the action contract: one registry, no prose fallback (#241 follow-up)#441
jeremymanning merged 1 commit into
mainfrom
fix/action-contract-closure

Conversation

@jeremymanning

Copy link
Copy Markdown
Member

Narrowly bounded follow-up to #241, as requested, before the Result API.

What was measured first

A census of 130 pipeline documents, counting only steps without a tool: key — steps with one route by tool and never touch the action vocabulary:

steps
dispatched by action name 430
already a registered action 247 (57%)
relying on the prose → model fallback 167 (53 distinct)
<AUTO> instructions 14
declared prose families (echo …) 2

My first census was wrong and counted write/execute/read as prose — they are tool operations in the two-field form. The numbers above are from parsed steps, not raw action: lines.

Among the 167: generate-text, used six times, had never dispatched. A hyphen slip of generate_text — the same defect class as generate_structured in #437, which is exactly the drift this PR closes.

The registry

ActionSpec carries canonical name, aliases, handler, whether a model or tool is required, required parameters, result schema and v1 membership. Everything else is derived:

Consumer Derived as
Executor dispatch resolve_action(...).handler
Validator recognition is_known_action(...)
supported_actions SUPPORTED_ACTIONS
Alias normalisation canonical_action(...), applied by the compiler
Documentation docs/actions.md, generated + drift-tested

The ten advertised-but-unimplemented names are gone. Anything advertised is now executable, with a test asserting exactly that.

Unknown actions are refused twice

At compile time, and again at dispatch — a caller can build a Task and reach execution without going through YAML, so compile-time validation is not the only boundary. Both are tested.

<AUTO>...</AUTO> stays supported and is now a declared ActionFamily rather than an accident of the fallback: an author explicitly asking the model to interpret an instruction is not the same as a typo falling through. echo … and the prose filesystem forms are declared the same way; their regexes moved into the registry and the two now-unused predicate methods are deleted.

Three real bugs fell out of writing the tests

  1. Registered actions handled one level down (generate_structured lives on ModelBasedControlSystem) hit the new raise instead of delegating.
  2. The filesystem handler keys off a hardcoded list containing "file" but not "filesystem". Normalising the alias sent canonical steps past that branch into a default that tried to write to the read-only root.
  3. Every failed step recorded the error handler's recovery decision{"action": "retry", "delay": 5.0, ...} — as its error. The reason a step failed was being discarded system-wide, and Task.error was not even an exception. Now the original error is recorded with the recovery decision beside it, and a compile failure leads with the actual messages instead of Issues: 1 errors.

Number 3 is the one worth noting for the Result API: structured errors were not merely missing, they were actively overwritten.

Also in here

  • A pre-existing SyntaxError in scripts/utilities/organization_maintenance.pyextend([ closed with ]. That file could not be imported at all. Found by extending the correctness lint to scripts/.
  • The cleanliness guard now catches untracked files, per your point 3: git status --porcelain --untracked-files=all, since git diff cannot see newly created ones. legacy-results.txt is excluded as the single expected artifact.
  • The exit-code boundary is documented, per your point 4: compile-time failures exit 2; runtime template and execution failures exit 1, with the reasoning about multi-pass resolution written down.
  • tests/test_builtin_actions.py folded into tests/test_action_contract.py (53 tests); its assertions were subsumed once the registry became authoritative.

Numbers

before after
Blocking suite 456 passed 476 passed, 13 skipped, 0 failed, 0 xfailed
Collection 3251 3271
Examples validating 3 / 111 3 / 111 — tightening cost no valid pipeline
ruff correctness (src + scripts) 1 SyntaxError clean
uv lock --check, git diff --check, ci.yml parse clean

Deliberately not here

Parameter schemas, only required-parameter lists. ActionSpec has the field, and the validator checks required names, but full JSON Schema per action means deciding the parameter vocabulary for every action — that belongs with the canonical YAML schema work, not smuggled into a closure PR.

Result schemas are declared for generate and generate_structured only — the two whose envelope shape is actually fixed today. Asserting a schema for handlers that return ad-hoc dicts would mean inventing their contracts here rather than in the Result API, where the per-step envelope gets designed once.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant